物件導向基礎與 prototype


Posted by saffran on 2021-02-25

原型鍊 文章推薦:該來理解 JavaScript 的原型鍊了

什麼是物件導向?

ES6 的物件導向(基礎範例)

範例一

  • class 的名稱一定要大寫開頭
  • 先用 class 畫出一個設計圖,列出 Dog 有哪些 method 可以用
  • 再用 new Dog() 從「Dog 這個 class」實際建立出一個物件(instance)

然後才可以使用 d.sayHello()

// 先用 class 畫出一個設計圖
class Dog {
  sayHello() {
    console.log("hello");
  }
}

var d = new Dog(); // 實際建立出一個物件(instance)
d.sayHello(); // hello

this 會指向「目前所在的 instance」

如果 this 是出現在物件導向「裡面」的話:

d.setName("harry") 代表「我要對 dsetName("harry")」,所以這個 this 就會指向「呼叫 setName("harry") 的 instance」,也就是 d

第 4 行的 this.name = name 就是把 dname 設為「我傳入的參數 name

setter 和 getter 是很常見的模式

  • setName(name) 函式稱為 setter,專門用來設定東西用的
  • getName() 函式稱為 getter,專門用來取得值

雖然用 d.name 也可以取得/更改值,但是不建議這樣寫。還是會建議用 class Dog 裡面提供的 method,也就是 d.getName()d.setName() 來取得/更改 dname 的值,這是比較好的開發習慣

// 先用 class 畫出一個設計圖
class Dog {
  // setter
  setName(name) {
    this.name = name; // this 會指向 d
  }

  // getter
  getName() {
    return this.name;
  }

  sayHello() {
    console.log("hello");
  }
}

var d = new Dog(); // 實際建立出一個物件(instance)
d.setName("harry");
console.log(d.getName()); // harry

constructor 函式做初始化

new Dog() 就像是一個 function call,可以傳參數進去,例如用 new Dog("danny") 把狗取名為 danny,用 new Dog("ben") 把狗取名為 ben

然後,在 class Dog 裡面就可以用 constructor 函式來接收參數

constructor 是一個特別的 function,稱為「建構子」,常用來做初始化

當我呼叫 new Dog("danny") 時,其實就是在呼叫 constructor 函式
所以,在 new Dog("danny") 裡面傳入的參數 danny,就可以在 constructor(name) 這裡的 name 接收到,並且把 this.name 設定為 danny

// 先用 class 畫出一個設計圖
class Dog {
  // 用 constructor 接收參數
  constructor(name) {
    this.name = name;
  }

  // setter
  setName(name) {
    this.name = name; // this 會指向 d
  }

  // getter
  getName() {
    return this.name;
  }

  sayHello() {
    console.log(this.name);
  }
}

var d = new Dog("danny"); // 實際建立出一個物件(instance)
d.sayHello(); // danny

var b = new Dog("ben");
b.sayHello(); // ben

console.log(d.sayHello === b.sayHello); // true

d.sayHello === b.sayHello 是 true 可以得知「d.sayHellob.sayHello 是同一個 function」,只是會根據 this 指向不同的 instance,而印出不同的 this.name

ES5 的 class

因為在 ES5 裡面,沒有 class 這個語法可以用,所以要用其他方式來實作出物件導向:

// ES5 的物件導向
function Dog(name) {
  let myName = name;
  return {
    getName: function () {
      return myName;
    },
    sayHello: function () {
      console.log(myName);
    },
  };
}

let d = Dog("danny");
d.sayHello(); // danny

let b = Dog("ben");
b.sayHello(); // ben

但是,這樣寫會有個問題是:
每呼叫一次 Dog 函式,都會產生一個新的物件,重新產生並回傳 getNamesayHello 這兩個 function

d.getName === b.getName 是 false 可以得知「d.getNameb.getName 是不同的兩個 function」

那如果我有 1000 隻狗,豈不是就會有 1000 個 getName 函式?這樣會很耗費記憶體

所以,應該要可以共用 getName 函式才對,因為都是要做同樣一件事情(就是要 get name 而已),其實只需要一個 getName 函式就夠了!

// ES5 的物件導向
function Dog(name) {
  let myName = name;
  return {
    getName: function () {
      return myName;
    },
    sayHello: function () {
      console.log(myName);
    },
  };
}

let d = Dog("danny");
let b = Dog("ben");
console.log(d.getName === b.getName); // false

要怎麼解決上面的問題呢?解法如下:

在 ES5,可以把 function 當作 constructor 來用,來實作出物件導向

只要我在呼叫 Dog("danny") 前面加上 new 這個關鍵字,JavaScript 就會幫我在背後做好這整個機制:把 Dog 函式,變成是 ES6 的 constructor 函式的意思

這樣一來,d 就會是一個 instance 了

// ES5 的物件導向
// Dog 函式,等同於是 ES6 的 constructor
function Dog(name) {
  this.name = name;
}

var d = new Dog("danny"); // 加上 new 這個關鍵字
console.log(d); // Dog { name: 'danny' }

prototypeDog 裡面建立 method

prototype 是 JS 的一個機制

Dog.prototype.sayHello 就可以幫 Dogprototype 加上 sayHello 這個 function 了

然後,用 d.sayHello() 就可以呼叫到 Dog.prototype 裡面的 sayHello 這個 function

這時,d.sayHellob.sayHello 會是同一個 function,因為這兩個都是在同一個 prototype 上面

// ES5 的物件導向
// Dog 函式,等同於是 ES6 的 constructor
function Dog(name) {
  this.name = name;
}

Dog.prototype.getName = function () {
  return this.name;
};

Dog.prototype.sayHello = function () {
  console.log(this.name);
};

var d = new Dog("danny");
d.sayHello(); // danny

var b = new Dog("ben");
b.sayHello(); // ben

console.log(d.sayHello === b.sayHello); // true

從 prototype 來看「原型鍊」

(ES6 的物件導向,在底層就是用 prototype 去實作出來的)

dDog 的一個 instance,dDog.prototype 之間,會透過 __proto__ 這個內部屬性給連接起來,這樣 JS 的引擎才會知道「呼叫 d.sayHello() 時,就要去 Dog.prototype 裡面找 sayHello 這個 function」

在 JS 有一個內部的屬性叫做 __proto__,這個屬性會暗示說「如果在 d 身上找不到 sayHello 的話,就要去 __proto__ 找」

console.log(d.__proto__) 印出的結果會是:

{ getName: [Function (anonymous)], sayHello: [Function (anonymous)] }

因為 dDog 的一個 instance,所以 d.__proto__ 會等於 Dog.prototype (這是 new 這個關鍵字幫我設定好的)

console.log(d.__proto__ === Dog.prototype); // true

當我呼叫 d.sayHello() 時,依序會是這樣的流程:

  1. d 身上找,有沒有 sayHello -> 沒有
  2. d.__proto__ 身上找,有沒有 sayHello
    因為 d.__proto__ = Dog.prototype,所以就會去 Dog.prototype 找到 sayHello 並呼叫,裡面的 this 就會是 d
  3. 如果還是沒找到 sayHello 的話,就會再往上一層的 d.__proto__.__proto__ 找,有沒有 sayHello
    因為 d.__proto__.__proto__ = Object.prototype,所以就會去 Object.prototype 找到 sayHello 並呼叫,裡面的 this 就會是 d
  4. 如果還是沒找到 sayHello 的話,就會再往上一層的 d.__proto__.__proto__.__proto__ 找,有沒有 sayHello
    會發現,d.__proto__.__proto__.__proto__ 已經回傳 null 了,就代表「已經找到頂了,都沒有找到這個 function」,那就會拋出錯誤「d.sayHello is not a function」
d.sayHello()

1. d 身上有沒有 sayHello
2. d.__proto__ 有沒有 sayHello
3. d.__proto__.__proto__ 有沒有 sayHello
4. d.__proto__.__proto__.__proto__ 有沒有 sayHello
5. null 找到頂了

d.__proto__ = Dog.prototype
d.__proto__.__proto__ = Object.prototype
Dog.prototype.__proto__ = Object.prototype

透過 __proto__ 這個內部的屬性所構成的「prototype chain 原型鍊」,一層一層往上找,看是否能找到對應的 function

如果 Dog.prototypeObject.prototype 同時都有 sayHello 的話

因為會先在 Dog.prototype 找到 sayHello,就不會再繼續往上找了,所以印出的結果會是 dog danny

// ES5 的物件導向
// Dog 函式,等同於是 ES6 的 constructor
function Dog(name) {
  this.name = name;
}

Dog.prototype.getName = function () {
  return this.name;
};

Dog.prototype.sayHello = function () {
  console.log("dog", this.name);
};

Object.prototype.sayHello = function () {
  console.log("object", this.name);
};

var d = new Dog("danny");

d.sayHello(); // dog danny

因為 Dog.__proto__ 是一個 Function,所以 Dog.__proto__ 會等於 Function.prototype

// ES5 的物件導向
// Dog 函式,等同於是 ES6 的 constructor
function Dog(name) {
  this.name = name;
}

Dog.prototype.getName = function () {
  return this.name;
};

Dog.prototype.sayHello = function () {
  console.log("dog", this.name);
};

Object.prototype.sayHello = function () {
  console.log("object", this.name);
};

var d = new Dog("danny");

console.log(Dog.__proto__); // Function
console.log(Dog.__proto__ === Function.prototype); // true

String.prototype

var a = "friday";
a.toString();

console.log(a.__proto__ === String.prototype); // true
console.log(a.toString === String.prototype.toString); // true

字串 a 本身並沒有 toString 這個 method 可以用

因為 a 是一個 String,所以 a.__proto__ 會等於 String.prototype
toString 這個 function 是放在 prototype 上面的

當我 call a.toString 時,其實是在 call String.prototype.toString

String.prototype 加上自訂的 function

我只要在 String.prototype 加上 first 這樣的一個 function,
任何一個字串就都可以用 first 這個 function 來取得字串的第 0 個字元

這裡的 this 指向的就是「呼叫 first 的字串」

// 取得字串的第 0 個字元
String.prototype.first = function () {
  return this[0];
};

var a = "friday";
console.log(a.first()); // f

所以,new 到底做了什麼事?

先補充一個預備知識:
test.call() 是另一種呼叫 function 的方式,call() 小括號裡面可以傳參數進去

如果我在第一個參數傳 123 進去,印出來的 this 就會是 123

意思就是

當我用 call() 來呼叫 function 時,在 call() 裡面傳入的第一個參數,就會是 function 裡面的 this

function test() {
  console.log(this);
}

test.call("123"); // [String: '123']

自己實作出 new 的機制

newDog() 要做的事情跟「new 這個關鍵字」一樣,這樣 b.sayHello() 才有辦法跑

第一步:Dog.call(obj, name)

這行 Dog.call(obj, name),就會去執行 Dog 這個 constructor 函式,因為 Dog 裡面的 this 會是 obj,所以就是 obj.name = name,所以在 obj 這個物件裡面就會有 name: 'ben' 這個資料了

obj 這個物件就是「在執行完 constructor 之後,可以把我在 newDog(name) 傳入的 name 放在裡面」

// ES5 的物件導向
// Dog 函式,等同於是 ES6 的 constructor
function Dog(name) {
  this.name = name;
}

Dog.prototype.getName = function () {
  return this.name;
};

Dog.prototype.sayHello = function () {
  console.log(this.name);
};

var d = new Dog("danny");
var b = newDog("ben"); // newDog() 要做的事情跟「new 這個關鍵字」一樣
// b.sayHello();

function newDog(name) {
  var obj = {};
  Dog.call(obj, name); // 執行 constructor,在 Dog 裡面的 this 會是 obj
  console.log(obj); // { name: 'ben' }
}

第二步:建立 obj.__proto__ = Dog.prototype 的連結

obj.__proto__ 給指定到 Dog.prototype,建立連結後,obj 也可以使用 Dog.prototype 上面的 function 了

第三步:把 obj 回傳回去

因為 var b = newDog("ben")newDog("ben") 最後會 return obj
-> 所以,obj 就會等同於 b,也就等同於是 Dog 的一個 instance 了

obj.__proto__ = Dog.prototype 這行就會等於是 b.__proto__ = Dog.prototype

所以,b 就可以去使用 Dog.prototype 上面的 sayHello 了,b.sayHello() 就可以跑了!(會印出我傳入的 ben

// ES5 的物件導向
// Dog 函式,等同於是 ES6 的 constructor
function Dog(name) {
  this.name = name;
}

Dog.prototype.getName = function () {
  return this.name;
};

Dog.prototype.sayHello = function () {
  console.log(this.name);
};

var d = new Dog("danny"); // Dog 裡面的 this 會是 d
var b = newDog("ben"); // newDog() 要變成是 Dog 的一個 instance,要做的事情跟「new 這個關鍵字」一樣
b.sayHello(); // ben

function newDog(name) {
  var obj = {}; // 產生一個新的 object
  Dog.call(obj, name); // 執行 constructor(也就是 Dog 函式),把 obj 當作 this 丟進 constructor 裡面
  // console.log(obj); // { name: 'ben' }
  obj.__proto__ = Dog.prototype; // 建立連結,讓 obj 也可以使用 Dog.prototype 上面的 function
  return obj; // 讓 obj 等同於 b
}

new 幫我做的事情

看完上面的範例後,可以知道 new 這個關鍵字幫我做的就是下面這幾件事情:

  1. 產生一個新的 object
    var obj = {};
    
  2. 幫我去呼叫 constructor 函式(在這裡的 constructor 就是 Dog 函式)
    Dog.call(obj, name)
    
  3. 把新產生的 object 當作 this 丟進 constructor 裡面
    function Dog(name) {
    this.name = name;
    }
    
  4. 幫我設定好 __proto__,讓 obj.__proto__ 去連到 Dog.prototype,這樣 b 才可以使用 sayHello 這個 function
    obj.__proto__ = Dog.prototype;
    
  5. 把 object 回傳回去,讓 object 等於 b(也就是 Dog 的一個 instance)
    return obj;
    

物件導向的繼承:Inheritance

物件導向有一個很有名的概念,叫做「繼承」

繼承的使用時機

需要用到一些共同的屬性時,就可以用繼承的方式(就不用所有東西都自己重新做)

範例:
有一種特殊品種的狗叫做 BlackDog

第 13 行 class BlackDog extends Dog 就是「讓 BlackDog 去繼承 Dog

BlackDog 繼承 Dog 之後,就可以使用 Dog 的每一個 method (function) 了!

就像是 BlackDogDog 的東西都拿過來了,所以 BlackDog 可以使用 constructor, sayHello, 以及自己的 test

當執行到第 19 行 const d = new BlackDog("danny")

  1. 因為在 BlackDog 裡面沒有寫 constructor,所以就會往上層的 parent 找(因為 BlackDog 繼承了 Dog,所以 BlackDog 的 parent 就是 Dog),在 Dog 找到了 constructor 後,就執行 constructor
  2. 執行完 constructor 之後,BlackDog 就擁有 this.name

當執行到第 21 行 d.test() 時,就會印出 test 裡面的 this.name

也可以用 d.sayHello() 來印出 this.name

// ES6 的物件導向
class Dog {
  constructor(name) {
    this.name = name;
  }

  sayHello() {
    console.log(this.name);
  }
}

// BlackDog 繼承了 Dog
class BlackDog extends Dog {
  test() {
    console.log("test!", this.name);
  }
}

const d = new BlackDog("danny");

d.test(); // test! danny
d.sayHello(); // danny

現在,我要做的功能是:在 BlackDog 被建立時,就呼叫 sayHello

錯誤寫法

// ES6 的物件導向
class Dog {
  constructor(name) {
    this.name = name;
  }

  sayHello() {
    console.log(this.name);
  }
}

// BlackDog 繼承了 Dog
class BlackDog extends Dog {
  // 錯誤寫法
  constructor() {
    this.sayHello(); // 在 BlackDog 被建立時,就呼叫 sayHello
  }
  test() {
    console.log("test!", this.name);
  }
}

const d = new BlackDog("danny");

執行之後會噴出錯誤「ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor」
意思就是:
在存取 this 之前,一定要先 call super
super 就是「上一層的 constructor」,也就是「Dog 的 constructor」

為什麼一定要先 call super 呢?
原因為:
如果沒有先 call super 的話,
在執行 BlackDog 裡面的 this.sayHello() 時,在 sayHello 裡面會用到 this.name,但是這時還沒有執行到 Dog 的 constructor,所以 this.name 還沒被初始化,就會造成 bug

因此,就強制一定要先 call super

可是,只有 call super 是沒用的

因為這時在 Dogconstructor 接收的 name 會是 undefined

那既然我已經在 BlackDog 複寫一個 constructor 了,那在 BlackDog 的 constructor 就要負責接收 name,並且用 super(name)name 也傳到 parent 的 constructor 去,讓 Dog 的 constructor 可以成功的初始化

這樣當我在 BlackDog 裡面 call this.sayHello 時,才會印出正確的 this.name

正確寫法:一定要先 call super,才能存取到 this

// ES6 的物件導向
class Dog {
  constructor(name) {
    this.name = name;
  }

  sayHello() {
    console.log(this.name);
  }
}

// BlackDog 繼承了 Dog
class BlackDog extends Dog {
  // 正確寫法
  constructor(name) {
    super(name); // 就是 Dog 的 constructor,把 name 傳到 Dog 的 constructor 去
    this.sayHello(); // 在 BlackDog 被建立時,就呼叫 sayHello(會印出 this.name)
  }
  test() {
    console.log("test!", this.name);
  }
}

const d = new BlackDog("danny");

#javascript







Related Posts

進入 Vue.js 前的 ES6 必備知識

進入 Vue.js 前的 ES6 必備知識

Leetcode JS 2627. Debounce

Leetcode JS 2627. Debounce

(2)類別圖型 - 類別節點

(2)類別圖型 - 類別節點


Comments